I was working with a
Vue
component. The goal of the component was very simple:- If the URL includes the right query parameters, then it will send a POST request to the backend
- Otherwise, it will throw you to the Generic error page.
Sounds simple, right?. Here is what was done in the component.
<template> <div class="ek-container"> <component :is="WidgetComponents[getWidgetComponents()]" :customer="currentCustomer" :agreement="currentAgreement" :originalAgreement="originalAgreement" :product="currentProduct" :originalProduct="originalProduct" :insuredObject="currentInsuredObject" :originalInsuredObject="originalInsuredObject" :coverage="currentCoverage" :originalCoverage="originalCoverage" :isLoadingDetails="isLoadingDetails" :errorDetails="errorDetailsComputed" /> </div> </template> <script> function getWidgetComponents() { const productCode = currentProduct.value?.code; if ( !showErrorPage.value && hasValidURLparams.value && hasValidProduct.value ) { if (productCode === ProductCodes.PREGNANCY) { return "PregnancyWidget"; } } return "GenericErrorPage"; } ... other codes </script>
This code defines a Vue.js component template with a container that dynamically renders different widgets based on the product's status and type, specifically checking if it’s a "Pregnancy" product. Here's a breakdown of the code's functionality, as well as positive and negative points:
Code Summary
- Template: A
div
container with a dynamic component (<component>
) that conditionally renders components based ongetWidgetComponents()
. It passes several props (customer
,agreement
,product
, etc.) to the rendered component.
- Dynamic Component Resolution: The
getWidgetComponents()
function determines the component type based on: - Validity of URL parameters (
hasValidURLparams
). - Product validity (
hasValidProduct
). - Whether
productCode
matchesProductCodes.PREGNANCY
.
- Conditional Rendering: If conditions are met and the product code is "Pregnancy," the
PregnancyWidget
component is shown; otherwise, it defaults toGenericErrorPage
.
Positive Points
- Modular and Reusable: Using dynamic components (
:is
) allows flexibility to display different widgets based on business logic, making it highly reusable.
- Error Handling: The fallback
GenericErrorPage
provides a safe default if parameters are invalid or the product doesn’t match.
- Parameterization: Passing specific props (
customer
,agreement
, etc.) enables flexibility for the child component to use relevant data.
Negative Points
- Tight Coupling of Conditions: The component heavily relies on a specific product code for conditional rendering. Adding new conditions (like other product types) may require more code duplication or refactoring.
- Limited Extensibility: If new product types require new widgets, the
getWidgetComponents()
function could become cluttered and harder to maintain.
- Code Organization:
getWidgetComponents
is embedded in the script without proper separation, making it less modular. Extracting it to a helper or computed property would improve readability and testability.
Recommendations
Consider refactoring
getWidgetComponents
to make it easier to add new conditions, potentially using a mapping approach for products to widget components. Also, consider modularizing the logic to improve readability and maintainability.In any case, we discard above issues and move head. Assuming, all query parameters are provided, we are supposed to create offer request. That could be done by sending
POST
request to backend. Here, is how it was implemented:And
onMounted
Layer 1:onMounted(async () => { ciaStore.showInlineError = false; const areRequestSame = areSameURLParamsRequest( ciaStore.lastValidURLParams, props.widgetProps ?? ({} as WidgetProps) ); const isSystemError = ErrorCode.SYSTEM_ERROR.includes( ciaStore.lastErrorCode ); const shouldSendRequest = hasValidURLparams?.value && hasValidProduct.value && (!areRequestSame || isSystemError); if (shouldSendRequest) { ciaStore.$reset(); ciaStore.lastValidURLParams = props.widgetProps ?? ({} as WidgetProps); await createChangeOfferRequest().catch( (errors: Error | any) => { setTimeout(() => { const errorDetails = getErrorMessageDetails( errors?.message ?? "", ciaStore.commonDrupalText as CommonDrupalText ); errorDetailsRef.value = errorDetails; ciaStore.isFullscreenLoading = errorDetails.errorKey === ErrorKey.SystemErrors; }, 300); } ); } });
Why is that issue?
- Why do we need
.then
if you’re already usingawait
. Theawait
keyword pauses execution until thecreateChangeOfferRequest()
promise resolves or rejects, allowing you to handle the result or error directly without chaining.then
or.catch
. However, if you want to catch errors when usingawait
, you can use atry...catch
block instead of.catch
.
- We are using
setTimeout
. We can not trustsetTimeout
as it can lead to a lot of side effects. JavaScript is single-threaded. Meaning, it can only calculate one thing at a time. But now imagine you have asetTimeout
that is suppose to fire after 10000ms or 10s. So now JS have to keep track of the time passed. But in between that 10s the user might do some interaction with your page. Now JavaScript have to react to them as well. Here is a very nice blog article about why we should be careful with setTimeout.
The component logic seems overly complex for its intended purpose. Let's break down the issues with this implementation:
- The use of multiple nested functions and promise chains makes the code difficult to read and maintain.
- Error handling is scattered across different layers, making it challenging to manage and debug effectively.
- The reliance on
setTimeout
for error handling introduces potential timing issues and unnecessary delays.
Layer 2: And then defining the function
createChangeOfferRequest
async function createChangeOfferRequest() { if ( props.widgetProps?.agreementId && props.widgetProps?.customerNo ) { const payload = { policyNumber: props.widgetProps?.agreementId, customerId: props.widgetProps?.customerNo, customerType: "privateCustomer", changeStartDate: moment .utc() .format("YYYY-MM-DDTHH:mm:ss.SSS[Z]") }; await ciaStore.createChangeOffer(payload); } }
What is the issue here?
- First of all, why do we need this second layer of function?
Layer 3: And then,
ciaStore
which is a Pinia
store. export const changeOfferActions = { async createChangeOffer( changeOfferRequest: ChangeOfferRequest ): Promise<void> { const state = useChangeOfferStore(); try { state.isFullscreenLoading = true; const changeOfferResponse = await createChangeOffer( changeOfferRequest ); updateEntities( state.originalEntities, changeOfferResponse ); updateEntities(state.entities, changeOfferResponse); state.offerId = changeOfferResponse.agreement?.offerNo ?? ''; state.customerId = changeOfferRequest.customerId ?? ''; } catch (error) { logInDev('Error creating change offer:', error); throw error; } state.isFullscreenLoading = false; }, .... };
Why could this be an issue?
- Why do we need this third layer?
- Issue:
state.isFullscreenLoading
is set tofalse
only after thetry...catch
block, meaning it will remaintrue
if an error occurs.
- Encapsulated State Updates: Keeping state mutation logic in dedicated state functions, like
updateEntities
, isolates side effects and improves testability.
Layer 4: Actual
createChangeOffer
method.export async function createChangeOffer( changeOfferRequest: ChangeOfferRequest ): Promise<Offer> { return new Promise(async (resolve, reject) => { await POST("/api/changePolicy/createChangeOffer", { headers: getAPIHeaders(), body: changeOfferRequest }) .then((responseFromServer) => { if (responseFromServer.error) { reject(responseFromServer.error); } else { resolve(responseFromServer.data as Offer); } }) .catch((error) => { if ( error instanceof TypeError && error.message === "Failed to fetch" ) { reject(new Error(ErrorCode.SYSTEM_ERROR)); } else { reject(error); } }); }); }
Why is issue with this?
- First of all, given the goal of the component, why do we need this?
- Issue: Wrapping
async
withinnew Promise
is redundant and unnecessary, asasync
functions inherently return promises. This leads to more complex code than needed.
- Issue: The current function handles only a
TypeError
with a specific message ("Failed to fetch"). It’s best to broaden the error handling to catch other network-related issues (e.g., 5xx server errors, bad request errors). Solution: Check the response status and structure the error handling to cover more cases.
- Issue: Using
.then()
and.catch()
within anasync
function reduces readability, asawait
withtry...catch
is generally clearer for handling asynchronous code.
Layer 5: The final
`POST`
callimport createClient, { Middleware } from "openapi-fetch"; const apiMiddleware: Middleware = { async onRequest({ request }) { try { return request; } catch (error) { throw error; } }, async onResponse({ response }) { return response; } }; export const { POST, GET } = client;
Refactored Version
It felt like too much engineering to me. So, I refactored the code to introduce Tanstack vue query. And this is how my component call looks now:
const { mutate } = useMutation({ mutationFn: createChangeOffer, onSuccess: (response) => { const offerResponse = response.data as Offer; updateEntities( ciaStore.originalEntities, offerResponse ); updateEntities(ciaStore.entities, offerResponse); ciaStore.customerId = props.widgetProps?.customerNo ?? ""; ciaStore.offerId = offerResponse?.agreement?.offerNo ?? ""; ciaStore.requestState = REQUEST_STATE.SUCCESS; }, onError: (error: CustomError) => { const errorDetails = getErrorMessageDetails( error?.messageCode as ErrorCode, ciaStore.commonDrupalText as CommonDrupalText ); errorDetailsRef.value = errorDetails; ciaStore.isFullscreenLoading = errorDetails.errorKey === ErrorKey.SystemErrors; ciaStore.requestState = REQUEST_STATE.ERROR; } }); onMounted(asycn () => { // ... other checks if(valid) { const payload = { policyNumber: props.widgetProps?.agreementId, customerId: props.widgetProps?.customerNo, customerType: "privateCustomer", changeStartDate: moment .utc() .format("YYYY-MM-DDTHH:mm:ss.SSS[Z]") }; mutate(payload); } })
And the
POST
call:export const createChangeOffer = async ( changeOfferRequest: ChangeOfferRequest ) => { return await POST("/api/changePolicy/createChangeOffer", { headers: getAPIHeaders(), body: changeOfferRequest }); };
Advantages
- Readability: Instead of a 4/5 layer of function chaining, now it just calls one function.
- With the Tanstack query, I can handle side effects directly without keeping too many variable references.
Original Code Issues
- Unnecessary promise chaining and complex layered structure in the original code
- Potential side effects from using setTimeout in error handling
- Redundant wrapping of async functions within new Promise
- Limited error handling, focusing only on specific TypeError
Refactored Solution
- Introduced Tanstack vue query for improved state management and side effect handling
- Simplified API call structure, reducing function layers
- Enhanced readability by eliminating multiple layers of function chaining
- Improved side effect management without relying on numerous variable references
Another version without third party dependencies
We can create actions in
Pinia
. And instead calling different layer of functions, we can directly call this action. For example, we could define action like:import { defineStore } from 'pinia'; import axios from 'axios'; export const useProductStore = defineStore('product', { state: () => ({ products: [], isLoading: false, error: null, }), actions: { async fetchProducts() { this.isLoading = true; this.error = null; try { const response = await axios.get('/api/products'); this.products = response.data; } catch (err) { this.error = err.message; } finally { this.isLoading = false; } }, }, });
And can be called directly:
onMounted(() => { fetchProducts(); });
One of the reason why I prefer Tanstack query is due to the fact that, it handles and provides a lot of state utilities by default. For example, often we need to show loading, pending state, error state. With this library, we do not need to explicitly handle such queries.
Summary
When I refactored this codebase, there was conflict of interest by one of the senior developer. I referred it as technical depth and was strongly opposed by the reviewer saying “I am selling the wrong picture”. May be its easier because I am used to it. That lead me to think, may be its better idea for me to acknowledge myself again, research again, read again and explicitly write down my findings. This article is not mean to blame anyone, this is meant for myself so, that I can educate myself.
Original Implementation Issues
- The
Vue
component used excessive promise chaining and complex layered structure, making the code difficult to read and maintain
- Unnecessary use of
setTimeout
for error handling introduced potential timing issues
- Multiple nested functions and scattered error handling made debugging challenging
Refactored Solution
- Introduced
Tanstack Vue Query
to improve state management and simplify API calls
- Reduced function layers and eliminated multiple layers of function chaining, enhancing readability
- Improved side effect management without relying on numerous variable references
Alternative Approach
- Suggested using
Pinia
actions for direct API calls without third-party dependencies
- Tanstack Query preferred for its built-in state utilities, handling loading, pending, and error states automatically